test(qa): ledger out-of-repo consumers’ specifiers against the published exports maps - #15717
Conversation
Committed as-is from a dead session's worktree, before verification, so a container restart cannot lose it. Verified and rewritten in the commits that follow. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
…nsumer-specifier-ledger
…e exports maps An `exports` map is a PACKAGING contract, and inside this monorepo nothing is sealed: every in-repo consumer reaches any file through a relative import, a vitest alias or a `paths` entry. So the repo could not observe a sealing regression at all, by construction — twice the seal 17.3.0 introduced (#13123) was found after publish, by the consumer, during an upgrade (#13662, #15325). `consumer-specifiers.ledger.json` imports that knowledge from outside: the bare specifiers named out-of-repo consumers import, each with the consumer, a date and the card that ratified it. The suite packs each ledgered package with `pnpm pack`, unpacks it into a throwaway `node_modules` outside the workspace, and resolves every specifier from there with Node's real resolver under both the `require` and `import` conditions — failing with a message that names the specifier AND the consumer it breaks. Option A of #15589 only. Option B (a changeset-gated seal declaration in `check:published-files`) is deliberately not here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
…nsumer-specifier-ledger
…nsumer-specifier-ledger
…h doors The set-equality assertion did exactly what its header said it would. #15611 ratified `./hook-body` and `./package.json` and merged while this branch was in flight; merging main turned the suite red naming the two lines to delete. They are deleted, so the ledger of losses has reached its floor: every specifier a named out-of-repo consumer imports now resolves from a packed tarball. The empty table is KEPT rather than removed. "Every ledgered specifier resolves" is the contract; "the sealed set is exactly empty" is the ratchet — the line an author has to edit, past a ⛔, to make a red green by recording a break instead of repairing it. Also separates two diagnoses the exports map cannot tell apart on its own: a specifier that fails with ERR_PACKAGE_PATH_NOT_EXPORTED is SEALED (the subject of this file), while one failing MODULE_NOT_FOUND is a declared door leading nowhere — the package is unbuilt, or `files` does not ship the path `exports` names. Now that all three doors are open, an unbuilt tree would otherwise have read as "hotcrm is broken". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
📓 Docs Drift Check
What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
|
ACCEPT — PM seat Reviewed against the three-dot diff at What the diff does
Measured / arbiter: the test needs a built Fences held: Recorded for triage, not decided here: #15715 (option B, with the measured 69 packages / 51 commits / 56 born-sealed / 13 retrofits; the dev recommends B1, born-vs-retrofit). Flip + arm once both required jobs read success. Generated by Claude Code |
Part of #15589 — option A only. Option B is filed as a decision card, #15715, and is deliberately not implemented here.
The gap
An
exportsmap is a packaging contract, and inside this monorepo nothing is sealed: every in-repo consumer reaches any file through a relative import, a vitest alias or apathsentry. So the repo could not observe a sealing regression at all — not as an oversight, but by construction. It cost the same event twice: 17.3.0 sealed@objectstack/cli(#13123), and both breaks were found after publish, by the consumer, during an upgrade — cloud'sobjectos-runtime(#13662) and hotcrm's hook-body harness (#15325).A fix therefore has to import knowledge from outside the repo. That is what the ledger is.
What this adds
packages/qa/downstream-contract/consumer-specifiers.ledger.json— hand-curated, with a$commentheader carrying purpose, an OWNER line (domain:devx), the shrink-only rule, and the entry shape. Three seeded entries, each withspecifier/consumer/since/ratifiedBy:@objectstack/cli/consoleobjectos-runtimenode server@objectstack/cli/hook-bodytest/helpers/action-sandbox.ts@objectstack/cli/package.json@objectstack/cli/package.json. #15589 attributes it to cloud as well, and no reading of #13123 / #13662 / #15325 records that, so it is not claimed. An uncited row would make this ledger exactly as trustworthy as the memory it replaces.test/consumer-specifier-ledger.test.tsholds it against the real maps. Resolving from inside the workspace would prove nothing — this package's ownnode_modules/@objectstack/cliis a symlink intopackages/cli, and tsconfigpathsand vitest aliases bypassexportsbesides. So each ledgered package is packed withpnpm pack, unpacked into a throwawaynode_modulesoutside the workspace, and every specifier is resolved by a childnodewith nothing of this workspace on its resolution path —createRequire().resolve()forrequire,import.meta.resolve()forimport. Failures name the specifier and the consumer.It is the technique
packages/cli/test/published-subpath-hook-body.pin.test.tsuses (landed with #15611), and the two are deliberately kept separate: that pin asks "does the CLI's map still spell the subpaths this repo ratified", from insidepackages/cli. This asks "does every specifier a named out-of-repo consumer imports still resolve". A pin over one package's map cannot see the next package that gains one; the ledger grows a row rather than a file.The ledger of losses shrank to empty, mid-branch
When this was written, two of the three specifiers did not resolve:
./hook-bodyand./package.jsonwere ratified only by PR #15611, then open. A test asserting "every ledgered specifier resolves" would have been permanently red and could not land, and dropping the two entries would have discarded the knowledge. So the red was ledgered:SEALED_TODAYrecorded exactly which specifiers this tree did not open, asserted as a set equality so the table could only shrink.#15611 then landed while this branch was in flight, and the equality did exactly what its header said: merging main turned the suite red naming the two lines to delete. They are deleted. The table is now empty — every ledgered specifier resolves from a packed tarball today.
The empty table is kept, because it and the contract say different things: "every ledgered specifier resolves" is the contract; "the sealed set is exactly empty" is the ratchet — the line an author must edit, past a ⛔, to make a red green by recording a break instead of repairing it.
Non-vacuity — measured, not asserted
Ablation on the final head (
a4421eb89),./consoledeleted frompackages/cli/package.jsonexportsin the worktree, never committed:"./console"occurrences 1 → 0,git diff HEAD --statnon-empty, resulting map printed.git hash-object=5d0ba78b18fc55d292d161974439b60d7c60bd43= the HEAD blob,git diff HEADempty,git statusclean.The probe returning a real
ERR_PACKAGE_PATH_NOT_EXPORTEDis what establishes the pipeline ran end to end — pack, unpack, real resolver.The shape checks are load-bearing for the same reason: the ledger must be non-empty, every ledgered package must declare an
exportsmap (an unsealed package resolves anything and proves nothing), every ledgered package must be a declared dependency, and at least one door must open, so a tarball that failed to unpack cannot read as "all sealed, as recorded". A new check also separates two diagnoses the map cannot tell apart on its own:ERR_PACKAGE_PATH_NOT_EXPORTEDis a seal;MODULE_NOT_FOUNDis a declared door leading nowhere — unbuilt, orfilesnot shipping whatexportsnames.CI wiring — measured
The suite does run on PR-side CI for a
packages/cli/package.jsonchange. Measured with #15611's own diff pinned as the base and head:The mechanism is the
@objectstack/clidevDependency this PR adds:turbo ls @objectstack/downstream-contractlists@objectstack/cliamong its dependencies, and at the merge base the package declared only@objectstack/spec,typescriptandvitest, so a cli-only diff could not reach it. Layer B is covered by the same edge —@objectstack/downstream-contract#testdeclaresdependsOn: ["^build"]inturbo.json, so its task hash moves with@objectstack/cli#buildand a full-list push or merge-queue run cannot replay a stale cached green.pnpm-lock.yaml, which still carried it); deleting the lockfile entry too made the lockfile itself a changed global input, andturbo ls --affectedfolds in uncommitted working-tree changes, which widened the affected set to 10 rather than removing the package. The claim above rests on turbo's own graph listing and the merge-base manifest, not on a mutation.No changeset:
@objectstack/downstream-contractis"private": true, so it publishes nothing.skip-changesetapplied via the additive labels endpoint and read back.⛔ No file outside
packages/qa/downstream-contract/**is edited (pluspnpm-lock.yaml, for that package's own new devDependency).packages/cli/**, rootpackage.json,.github/workflows/**andscripts/check-published-files.mjsare untouched — several are held by other open PRs.check:cross-package-test-inputspasses without a new declaration:OK: 27 package(s) read outside themselves, all declared.Option B, filed with a measurement — #15715
B would refuse a PR that introduces an
exportsmap unless the changeset is at leastminorwith aBREAKING-style consumer note. Measured over this repo's history ona4421eb89:exportsmap todayA package born with a map seals nobody — there is no published predecessor to deep-import it. So B as worded fires on 51 commits to catch a class that lives in 7, roughly 7:1 against, with the false positives clustered on new-package PRs. That is the substance of #15715, which recommends a born-vs-retrofit discriminator over B as literally worded. (The 69 is independently corroborated by
check:published-files: "69 declare anexportsmap".)Verification — all on final head
a4421eb89pnpm --filter '@objectstack/cli...' build— exit 0.Test Files 1 passed (1) / Tests 12 passed (12).Test Files 3 passed (3) / Tests 31 passed (31);typecheckexit 0 (its tsconfigincludecoverstest/**/*, so the new file is in the program).node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstackon this head with no stale-tree warning: 85 commands run, 80 exit 0. Verdict lines quoted rather than bare$?; exit codes captured before any pipe:NOT MEASURED (5) — none of them a red
check:partof-closing-keyword,check:single-claim-paths— both printNOT WIRED: noPR_BODY/PR_NUMBERlocally. Re-run against this PR once it exists.check:dual-build-cjs-loads—PREREQUISITE NOT MET … ⛔ This is NOT a pass: nothing was measured(16 packages unbuilt; needs a fullpnpm build).check:published-readme-exports— same cause, 16 unbuilt packages. Already filed as [finding] check:published-readme-exports reports an unbuilt workspace as 40+ README findings — exit 1 where its sibling exits 3 PREREQUISITE NOT MET #15285 and [finding] check:published-readme-exports answers an UNBUILT workspace closure with exit 1 and 40+Build firstlines — indistinguishable from a real finding, where its sibling check-type-check-coverage models the same precondition as exit 3PREREQUISITE NOT MET#15600.check:react-declaration-parity—MANIFEST is not set … This gate did NOT run; needs an objectui build and a browser.🤖 Generated with Claude Code
https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
Generated by Claude Code